all repos — weimar @ fe3bf26c76d0c554ef9e8f0c9f52038b37ea886e

Unnamed repository; edit this file 'description' to name the repository.

web/src/routes/image/[id]/+page.svelte (view raw)

  1<script lang="ts">
  2	import { onMount } from 'svelte';
  3	import { getImage, addTags, removeTags, suggestTags, imageDownloadUrl, type ImageDetail } from '$lib/api';
  4
  5	let { params } = $props();
  6	let id = $derived(parseInt(params.id));
  7
  8	let img = $state<ImageDetail | null>(null);
  9	let loading = $state(true);
 10	let error = $state('');
 11
 12	let tagInput = $state('');
 13	let suggestions = $state<string[]>([]);
 14	let showSuggestions = $state(false);
 15	let tagError = $state('');
 16
 17	onMount(async () => {
 18		try {
 19			img = await getImage(id);
 20		} catch (err) {
 21			error = err instanceof Error ? err.message : 'Failed to load image';
 22		} finally {
 23			loading = false;
 24		}
 25	});
 26
 27	function formatSize(bytes: number): string {
 28		if (bytes < 1024) return bytes + ' B';
 29		if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
 30		return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
 31	}
 32
 33	function formatDate(s: string): string {
 34		return new Date(s).toLocaleString();
 35	}
 36
 37	async function onTagInput() {
 38		const q = tagInput.split(',').pop()?.trim() || '';
 39		if (q.length < 1) {
 40			suggestions = [];
 41			showSuggestions = false;
 42			return;
 43		}
 44		try {
 45			const result = await suggestTags(q);
 46			suggestions = result.map((t) => t.tag);
 47			showSuggestions = suggestions.length > 0;
 48		} catch {
 49			suggestions = [];
 50			showSuggestions = false;
 51		}
 52	}
 53
 54	function selectSuggestion(s: string) {
 55		const parts = tagInput.split(',').slice(0, -1);
 56		parts.push(s);
 57		tagInput = parts.join(', ') + ', ';
 58		showSuggestions = false;
 59	}
 60
 61	async function handleAddTags() {
 62		if (!img) return;
 63		const tags = tagInput.split(',').map((t) => t.trim()).filter(Boolean);
 64		if (tags.length === 0) return;
 65		tagError = '';
 66		try {
 67			const result = await addTags(img.id, tags);
 68			img.tags_detail = result.tags_detail;
 69			img.tags = result.tags_detail.map((t) => t.tag);
 70			tagInput = '';
 71		} catch (err) {
 72			tagError = err instanceof Error ? err.message : 'Failed to add tags';
 73		}
 74	}
 75
 76	async function handleRemoveTags() {
 77		if (!img) return;
 78		const tags = tagInput.split(',').map((t) => t.trim()).filter(Boolean);
 79		if (tags.length === 0) return;
 80		tagError = '';
 81		try {
 82			const result = await removeTags(img.id, tags);
 83			img.tags_detail = result.tags_detail;
 84			img.tags = result.tags_detail.map((t) => t.tag);
 85			tagInput = '';
 86		} catch (err) {
 87			tagError = err instanceof Error ? err.message : 'Failed to remove tags';
 88		}
 89	}
 90</script>
 91
 92{#if loading}
 93	<p class="state-msg">Loading...</p>
 94{:else if error}
 95	<p class="state-msg error">{error}</p>
 96{:else if img}
 97	<div class="page-head">
 98		<h1>{img.filename}</h1>
 99		<div class="head-line"></div>
100	</div>
101
102	<div class="layout">
103		<div class="preview">
104			{#if img.thumbnail_path}
105				<img src={imageDownloadUrl(img.id)} alt={img.filename} />
106			{:else}
107				<div class="no-thumb">NO THUMBNAIL</div>
108			{/if}
109		</div>
110
111		<div class="meta">
112			<div class="meta-block uploader-block">
113				<span class="meta-label">UPLOADED BY</span>
114				<div class="uploader-row">
115					{#if img.avatar_url}
116						<img src={img.avatar_url} alt="" class="avatar-img" />
117					{/if}
118					<a href="/browse?uploader={encodeURIComponent(img.uploader_username)}" class="meta-value uploader-link">{img.uploader_username}</a>
119				</div>
120			</div>
121			<div class="meta-block">
122				<span class="meta-label">ID</span>
123				<span class="meta-value">#{img.id}</span>
124			</div>
125			<div class="meta-block">
126				<span class="meta-label">DIMENSIONS</span>
127				<span class="meta-value">{img.width} &times; {img.height}</span>
128			</div>
129			<div class="meta-block">
130				<span class="meta-label">FILE SIZE</span>
131				<span class="meta-value">{formatSize(img.file_size)}</span>
132			</div>
133			<div class="meta-block">
134				<span class="meta-label">TYPE</span>
135				<span class="meta-value">{img.mime_type}</span>
136			</div>
137			<div class="meta-block">
138				<span class="meta-label">UPLOADED</span>
139				<span class="meta-value">{formatDate(img.created_at)}</span>
140			</div>
141			<div class="meta-block tags-block">
142				<span class="meta-label">TAGS</span>
143				<div class="tag-pills">
144					{#if img.tags_detail.length > 0}
145						{#each img.tags_detail as t (t.tag)}
146							<a href="/browse?tag={encodeURIComponent(t.tag)}" class="tag-pill" title="Used {t.usage_count} time{t.usage_count === 1 ? '' : 's'}">
147								<span class="tag-name">{t.tag}</span>
148								<span class="tag-count">{t.usage_count}</span>
149							</a>
150						{/each}
151					{:else}
152						<span class="none">none</span>
153					{/if}
154				</div>
155			</div>
156
157			{#if img.is_owner}
158				<div class="meta-block edit-tags-block">
159					<span class="meta-label">EDIT TAGS</span>
160					<div class="tag-input-row">
161						<div class="tag-wrapper">
162							<input type="text" placeholder="funny, meme, cat" bind:value={tagInput}
163								oninput={onTagInput}
164								onblur={() => setTimeout(() => { showSuggestions = false; }, 200)}
165							/>
166							{#if showSuggestions}
167								<ul class="suggestions">
168									{#each suggestions as s}
169										<li role="button" tabindex="0" onmousedown={() => selectSuggestion(s)}>{s}</li>
170									{/each}
171								</ul>
172							{/if}
173						</div>
174						<button class="tag-btn add" onclick={handleAddTags}>ADD</button>
175						<button class="tag-btn remove" onclick={handleRemoveTags}>REMOVE</button>
176					</div>
177					{#if tagError}
178						<span class="tag-err">{tagError}</span>
179					{/if}
180				</div>
181			{/if}
182
183			<a href={imageDownloadUrl(img.id)} class="btn" download={img.filename}>
184				DOWNLOAD ORIGINAL
185			</a>
186		</div>
187	</div>
188
189	<p class="back"><a href="/browse">&larr; BACK TO BROWSE</a></p>
190{/if}
191
192<style>
193	.state-msg {
194		padding: 2rem 0;
195		font-size: 1.1rem;
196		color: #888;
197	}
198	.error {
199		color: #c62828;
200	}
201
202	.page-head {
203		margin-bottom: 1.5rem;
204	}
205	.page-head h1 {
206		word-break: break-all;
207	}
208	.head-line {
209		height: 3px;
210		background: #c62828;
211		width: 3rem;
212		margin-top: 0.4rem;
213	}
214
215	.layout {
216		display: flex;
217		gap: 2rem;
218		flex-wrap: wrap;
219	}
220	.preview {
221		flex: 1;
222		min-width: 300px;
223		background: #1a1a1a;
224		display: flex;
225		align-items: center;
226		justify-content: center;
227	}
228	.preview img {
229		display: block;
230		width: 100%;
231		height: auto;
232		max-height: 70vh;
233		object-fit: contain;
234	}
235	.no-thumb {
236		padding: 4rem 2rem;
237		color: #555;
238		font-family: 'Oswald', sans-serif;
239		font-weight: 500;
240		font-size: 0.9rem;
241		letter-spacing: 0.12em;
242	}
243
244	.meta {
245		width: 260px;
246		flex-shrink: 0;
247		display: flex;
248		flex-direction: column;
249		gap: 1rem;
250	}
251	.meta-block {
252		display: flex;
253		flex-direction: column;
254		gap: 0.15rem;
255	}
256	.meta-label {
257		font-family: 'Oswald', sans-serif;
258		font-weight: 500;
259		font-size: 0.7rem;
260		letter-spacing: 0.15em;
261		color: #999;
262	}
263	.meta-value {
264		font-family: 'Inter', sans-serif;
265		font-size: 0.9rem;
266		font-weight: 600;
267		color: #1a1a1a;
268	}
269
270	.uploader-row {
271		display: flex;
272		align-items: center;
273		gap: 0.4rem;
274	}
275	.uploader-link {
276		color: #1a1a1a;
277		text-decoration: none;
278	}
279	.uploader-link:hover {
280		color: #c62828;
281		text-decoration: underline;
282	}
283
284	.avatar-img {
285		width: 1.5rem;
286		height: 1.5rem;
287		object-fit: cover;
288		flex-shrink: 0;
289	}
290
291	.tags-block {
292		flex: 1;
293	}
294	.tag-pills {
295		display: flex;
296		flex-wrap: wrap;
297		gap: 0.35rem;
298	}
299	.tag-pill {
300		display: inline-flex;
301		align-items: center;
302		gap: 0.3rem;
303		background: #1a1a1a;
304		padding: 0.25rem 0.5rem 0.25rem 0.65rem;
305		font-family: 'Inter', sans-serif;
306		font-size: 0.78rem;
307		font-weight: 500;
308		color: #eee;
309		text-decoration: none;
310		transition: background 0.15s;
311	}
312	.tag-pill:hover {
313		background: #c62828;
314		text-decoration: none;
315	}
316	.tag-name {
317		font-weight: 600;
318	}
319	.tag-count {
320		background: rgba(255, 255, 255, 0.12);
321		padding: 0.05rem 0.35rem;
322		font-size: 0.68rem;
323		font-weight: 600;
324		color: rgba(255, 255, 255, 0.65);
325		min-width: 1.1rem;
326		text-align: center;
327	}
328	.tag-pill:hover .tag-count {
329		background: rgba(0, 0, 0, 0.2);
330	}
331	.none {
332		color: #bbb;
333		font-size: 0.85rem;
334	}
335
336	.btn {
337		display: inline-flex;
338		align-items: center;
339		align-self: flex-start;
340		padding: 0.55rem 1.25rem;
341		background: #c62828;
342		color: #fff;
343		border: none;
344		font-family: 'Oswald', sans-serif;
345		font-weight: 500;
346		font-size: 0.8rem;
347		letter-spacing: 0.1em;
348		text-decoration: none;
349		text-transform: uppercase;
350		cursor: pointer;
351		transition: background 0.15s;
352		margin-top: 0.5rem;
353	}
354	.btn:hover {
355		background: #b71c1c;
356		text-decoration: none;
357	}
358
359	.back {
360		margin-top: 2rem;
361	}
362	.back a {
363		font-family: 'Oswald', sans-serif;
364		font-weight: 500;
365		font-size: 0.85rem;
366		letter-spacing: 0.1em;
367	}
368
369	.edit-tags-block {
370		border-top: 2px solid #1a1a1a;
371		padding-top: 0.75rem;
372	}
373	.tag-input-row {
374		display: flex;
375		gap: 0.35rem;
376		align-items: flex-start;
377	}
378	.tag-wrapper {
379		flex: 1;
380		position: relative;
381		min-width: 0;
382	}
383	.tag-wrapper input[type="text"] {
384		width: 100%;
385		padding: 0.4rem 0.5rem;
386		border: 2px solid #1a1a1a;
387		border-radius: 0;
388		font-family: 'Inter', sans-serif;
389		font-size: 0.78rem;
390		font-weight: 500;
391		background: #fff;
392		box-sizing: border-box;
393	}
394	.tag-wrapper input[type="text"]:focus {
395		outline: none;
396		border-color: #c62828;
397	}
398	.suggestions {
399		position: absolute;
400		top: 100%;
401		left: 0;
402		right: 0;
403		background: #fff;
404		border: 2px solid #1a1a1a;
405		border-top: none;
406		list-style: none;
407		margin: 0;
408		padding: 0;
409		z-index: 10;
410		max-height: 130px;
411		overflow-y: auto;
412	}
413	.suggestions li {
414		padding: 0.35rem 0.5rem;
415		cursor: pointer;
416		font-family: 'Inter', sans-serif;
417		font-size: 0.78rem;
418		font-weight: 500;
419	}
420	.suggestions li:hover {
421		background: #f0f0f0;
422	}
423	.tag-btn {
424		padding: 0.4rem 0.65rem;
425		border: none;
426		font-family: 'Oswald', sans-serif;
427		font-weight: 500;
428		font-size: 0.72rem;
429		letter-spacing: 0.08em;
430		cursor: pointer;
431		white-space: nowrap;
432		transition: background 0.15s;
433	}
434	.tag-btn.add {
435		background: #1a1a1a;
436		color: #fff;
437	}
438	.tag-btn.add:hover {
439		background: #2e7d32;
440	}
441	.tag-btn.remove {
442		background: #1a1a1a;
443		color: #fff;
444	}
445	.tag-btn.remove:hover {
446		background: #c62828;
447	}
448	.tag-err {
449		display: block;
450		margin-top: 0.3rem;
451		color: #c62828;
452		font-family: 'Inter', sans-serif;
453		font-size: 0.72rem;
454		font-weight: 600;
455	}
456</style>